우아한 컴포넌트 만들기: React 컴파운드 패턴
2025-06-06
널리 사용되는 UI 라이브러리인 shadcn/ui를 사용하다 보면 아래와 같은 컴포넌트 구조를 자주 볼 수 있습니다.
예를 들어, <Tabs>
컴포넌트의 사용 방식은 다음과 같습니다:
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
export function Example() {
return (
<Tabs defaultValue="account">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">Account content</TabsContent>
<TabsContent value="password">Password content</TabsContent>
</Tabs>
);
}
이처럼 여러 컴포넌트가 긴밀하게 협력하여 동작하는 구조를 컴파운드 컴포넌트 패턴(Compound Component Pattern)이라고 부릅니다.
이번 글에서는 이 패턴을 활용해 아코디언 컴포넌트를 직접 구현해보겠습니다.
컴파운드 컴포넌트 패턴이란?
웹 개발에서는 서로 상태를 공유하며 동작해야 하는 컴포넌트들이 많습니다. 예를 들어, 탭, 아코디언, 드롭다운 등에서는 각 구성 요소들이 서로를 인식하고 함께 작동해야 하죠.
컴파운드 컴포넌트 패턴은 이런 복잡한 상호작용을 우아하게 해결할 수 있는 React 디자인 패턴입니다.
HTML의 <select>
와 <option>
처럼 자연스럽고 선언적인 API를 만들 수 있다는 것이 큰 장점입니다.
실전 예제: FAQ 아코디언 만들기
1단계: Context API로 상태 관리하기
Accordion 컴포넌트는 내부 아이템들이 공유할 상태(activeItem)를 관리합니다. 클릭된 아이템을 토글하는 toggleItem 함수와 함께, 이 상태를 Context로 하위에 전달합니다.
import React, { createContext, useContext, useState } from "react";
const AccordionContext = createContext();
function Accordion({ children, defaultValue = null }) {
const [activeItem, setActiveItem] = useState(defaultValue);
const toggleItem = (value) => {
setActiveItem(activeItem === value ? null : value);
};
const contextValue = { activeItem, toggleItem };
return (
<AccordionContext.Provider value={contextValue}>
<div className="accordion">{children}</div>
</AccordionContext.Provider>
);
}
2단계: 아코디언 아이템 구성하기
AccordionItem은 트리거(질문)와 콘텐츠(답변)를 감싸는 래퍼입니다. value 속성을 통해 어떤 항목인지 식별합니다.
function AccordionItem({ children, value }) {
return (
<div className="accordion-item" data-value={value}>
{children}
</div>
);
}
클릭 시 아코디언을 열고 닫는 버튼입니다. 현재 활성화된 항목인지 판단하여 스타일과 아이콘 회전을 조절합니다.
function AccordionTrigger({ children, value }) {
const { activeItem, toggleItem } = useContext(AccordionContext);
const isActive = activeItem === value;
return (
<button
className={`accordion-trigger ${isActive ? "active" : ""}`}
onClick={() => toggleItem(value)}
aria-expanded={isActive}
>
<span className="accordion-title">{children}</span>
<span className={`accordion-icon ${isActive ? "rotated" : ""}`}>▼</span>
</button>
);
}
AccordionTrigger에 의해 열릴 콘텐츠 영역입니다. 현재 선택된 value와 비교해 표시 여부를 제어합니다.
function AccordionContent({ children, value }) {
const { activeItem } = useContext(AccordionContext);
const isActive = activeItem === value;
return (
<div className={`accordion-content ${isActive ? "expanded" : "collapsed"}`}>
<div className="accordion-content-inner">{children}</div>
</div>
);
}
Accordion.Item = AccordionItem;
Accordion.Trigger = AccordionTrigger;
Accordion.Content = AccordionContent;
3단계: 완성된 아코디언 사용 예시
FAQ 페이지처럼 여러 질문과 답변을 보여줄 때 유용합니다. 각각의 질문은 Accordion.Item으로 감싸고, 질문/답변은 Trigger, Content로 구성합니다.
export default function FAQPage() {
return (
<div className="faq-container">
<h1>자주 묻는 질문</h1>
<Accordion defaultValue="shipping">
<Accordion.Item value="shipping">
<Accordion.Trigger value="shipping">
🚚 배송은 얼마나 걸리나요?
</Accordion.Trigger>
<Accordion.Content value="shipping">
일반 배송은 2-3일, 익일배송은 다음날 오후 6시까지 도착합니다. 제주도
및 도서산간 지역은 1-2일 추가 소요될 수 있습니다.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="return">
<Accordion.Trigger value="return">
🔄 교환/환불은 어떻게 하나요?
</Accordion.Trigger>
<Accordion.Content value="return">
상품 수령 후 7일 이내 마이페이지에서 교환/환불 신청이 가능합니다.
단순 변심의 경우 배송비는 고객 부담입니다.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="size">
<Accordion.Trigger value="size">
📏 사이즈가 맞지 않으면 어떻게 하나요?
</Accordion.Trigger>
<Accordion.Content value="size">
사이즈 불만족 시 무료 교환 서비스를 제공합니다. 상품 페이지의 사이즈
가이드를 꼭 확인해주세요.
</Accordion.Content>
</Accordion.Item>
</Accordion>
</div>
);
}
React.Children을 활용한 대안
이 방식은 Context 없이도 상태 공유가 가능합니다. cloneElement를 사용해 자식 컴포넌트에 상태와 제어 함수를 직접 주입합니다. 다만 구조가 제한적입니다.
function Accordion({ children, defaultValue = null }) {
const [activeItem, setActiveItem] = useState(defaultValue);
const toggleItem = (value) => {
setActiveItem(activeItem === value ? null : value);
};
return (
<div className="accordion">
{React.Children.map(children, (child) =>
React.cloneElement(child, { activeItem, toggleItem })
)}
</div>
);
}
AccordionItem은 자식 컴포넌트에 필요한 prop을 전달하며 계층을 유지합니다.
function AccordionItem({ children, value, activeItem, toggleItem }) {
return (
<div className="accordion-item">
{React.Children.map(children, (child) =>
React.cloneElement(child, {
value,
activeItem,
toggleItem,
isActive: activeItem === value,
})
)}
</div>
);
}
⚠️ 주의사항
1. React.Children 사용 시 구조 제한
// ❌ 작동하지 않음
<Accordion>
<div>
<Accordion.Item value="test">
<Accordion.Trigger value="test">질문</Accordion.Trigger>
</Accordion.Item>
</div>
</Accordion>
// ✅ 작동함
<Accordion>
<Accordion.Item value="test">
<Accordion.Trigger value="test">질문</Accordion.Trigger>
</Accordion.Item>
</Accordion>
2. Props 충돌 주의
cloneElement
로 props를 주입할 때 기존 props와 충돌하지 않도록 주의해야 합니다.
3. TypeScript 통합 시 복잡성
컴파운드 패턴은 타입 추론이 어려워지기 때문에 별도의 타입 관리 전략이 필요합니다.
- 디버깅을 쉽게 하기 위해
displayName
설정:
AccordionItem.displayName = "Accordion.Item";
AccordionTrigger.displayName = "Accordion.Trigger";
AccordionContent.displayName = "Accordion.Content";
- PropTypes 또는 TypeScript로 prop 검증 추가:
Accordion.propTypes = {
defaultValue: PropTypes.string,
children: PropTypes.node.isRequired,
};
마무리
이번 글에서는 Context API를 이용한 Accordion UI 컴포넌트 예제를 통해 이 패턴의 구조와 동작 방식을 살펴보았습니다. UI 라이브러리를 만들거나 재사용 가능한 컴포넌트를 설계할 때 유용하게 활용할 수 있는 방법이니, 직접 구현해 보며 익혀 보시길 추천드립니다.